JVM执行字节码指令时有几种不同的方式,如以字节码解释器来模拟字节码的执行和将全部代码编译为针对某个平台的本地代码再执行。
早期的JVM使用解释器来模拟字节码指令的执行。为了简化实现,解释器就是在一个主函数中加上一个包含了所有操作码的分支跳转结构。调用该函数时,会附带上表示操作数栈和局部变量的数据结构,以此作为字节码操作的输入输出。总体来看,解释器的核心代码最多也就几千行。
纯解释执行这种方式简单有效,如果想要添加对新硬件架构的支持时,只需简单修改代码,重新编译即可,无需编写新的本地编译器。此外,写一个本地编译器的代码量也比写一个使用分支跳转结果的纯解释器大得多。
解释器在执行字节码时几乎不需要记录额外信息。而编译执行的JVM会将一些或全部字节码编译为本地代码,这时就需要跟踪所有经过编译的代码。如果某个方法在运行过程中发生了改变,就需要重新生成代码。相比之下,解释器只需要再解释一遍新的字节码就可以了。
因为解释执行所需要记录的额外信息最少,所以就很适用于像JVM这样随时代码可能发生变化的自适应运行时。
当然,相比于执行编译为本地代码的方式,纯解释执行的性能很差。曾经,Sun公司的 Classic Virtual Machine起初就是使用纯解释执行的方式,后来还是放弃了。
之前的示例代码中,编译后的方法只有4个字节码指令,而使用C语言编写解释器的话,可能需要多达10倍的本地指令才能完成。相比之下,编译为本地代码的add
方法最多只需要两条汇编指令就足够了(add
和return
)。
int evaluate(int opcode, int* stack, int* localvars) {
switch (opcode) {
...
case iload_1:
case iload_2:
int lslot = opcode - iload_1;
stack[sp++] = localvars[lslot];
break;
case iadd:
int sum = stack[--sp] + stack[--sp];
stack[sp++] = sum;
break;
case ireturn:
return stack[--sp];
...
}
}
上面的示例代码是以伪代码展示解释器如何执行add
方法,从中可以看到,即使是如此简单的add
方法,在JVM中运行时,也需要数十条汇编指令才能完成。这就是纯解释执行效率差的原因。
在x86平台上,经过JIT编译的add
会生成如下代码:
add eax, edx // eax = edx+eax
ret // return eax
有时为了更好的阐述观点,书中会贴出一些汇编代码,但不要担心,即使读者之前没有学习过汇编语言,也能够明白其中的含义。但是,为了更好理解本书的内容,读者最好能知道一些低级语言的基本概念。
曾经,为了避免解释执行字节码产生的性能问题,程序员迫不得已使用了一些“费力不讨好”的方法,即静态编译。那时,Java程序会被直接编译为本地代码,这种方式称为 预编译(ahead-of-time compilation)。其实,就是把Java当C++用。
随着Java中静态编译的完善,90年代(译者注,这里指的是20世纪90年代)后期市场上出现了不少这样的产品,它们将字节码转换为C语言代码,再将之编译为本地代码。大部分情况下,静态编译生成的代码的执行效率比纯解释执行高得多。不过,这种方式却抛弃了Java语言的动态特性,也无法妥善应对在运行过程中代码发生变化的情况。
静态编译的一大劣势就是摒弃了Java语言平台独立的特性。在这里,JVM已经被无视了。
此外,还有一个缺点就是Java中的内存本来是自动管理的,现在却不得不手动执行管理操作,严重影响伸缩性。
随着Java语言的动态特性受到广泛关注,其在服务器端发挥的作用越来越大,静态编译模式也变得越来越不实用。例如,服务器端应用程序可能会在运行过程中产生大量的 JSP(Java Server Page)将静态编译器简化为JIT编译,可以减小对自适应性的影响。
尽管静态预编译并不适用于实现Java语言,但可以用在其他地方,例如 预分析(ahead-of-time analysis)。在程序运行时进行分析,会消耗部分资源,影响主体业务的运行。如果能够在程序运行之前就能完成相关分析,就可以使程序运行得更好。例如,class文件中可能存在一些注解形式的分析数据,可以用来执行预分析。
另一种加速字节码执行速度的方法是彻底抛弃解释器,当首次调用某个Java方法时,将其编译为本地代码。这种编译方式是发生在运行时的,在JVM内部完成,因此不属于预编译范畴。
与静态预编译不同,在运行时编译更适合具有动态特性的Java。
完全JIT编译的好处是不需要维护解释器,但缺点是编译时间影响主体业务程序的运行。编译器对所有方法一视同仁,在编译那些热点方法的同时,也会编译那些执行次数较少的,甚至是只执行一次的方法。这些方法本可以解释执行的。
经常调用的方法称为 热方法,而那些不经常调用的,对程序的整体性能没什么影响的方法是 冷方法。
(译者注,这里之所以不用 热点方法一词是为了与 HotSpot加以区分)
上面提到的问题,可以通过在JIT编译器中添加不同层级的编译操作来修正。例如,在首次调用某个方法的时候,先提供一个优化的不很完善的版本,当JVM探查到某个方法是热方法时,例如对该方法的调用次数超过了某个阈值,则准备重新编译这个方法,这时可以使用一些复杂的优化方法了。当然,这种方式花费更多的时间在编译上。
完全JIT编译的主要缺点在于生成代码的速度太慢。对同一个方法来说,编译为本地代码后的执行效率比直接解释执行高数百倍,但准备时间却长数百倍。使用完全JIT编译这种方式时需要特别注意的是,尽管检测热方法的机制比较先进,但仍要慎重考虑执行效率和准备时间的问题,权衡得失。就算运行时使用快速、不完全的JIT编译器,其准备时间仍然比纯解释执行慢得多,因为解释器跟不做编译工作。
使用完全JIT编译的另一个问题是,在程序运行过程中会产生大量废弃代码。如果某个方法需要重新生成,例如由于编译器之前所作的假设失效或由于对方法进行了优化,这时在生成了新代码之后,之前生成的老代码仍然会占用内存。因此,JVM需要某种“垃圾回收”机制来清理这些已经废弃的代码,否则,对于大量使用JIT编译的系统来说,最终会由于代码缓冲区容量的增长而消耗掉所有内存资源。
JRockit JVM的代码生成策略是在完全JIT编译的基础上,加以优化整改而成的。
最早提出的、在不牺牲Java动态特性的情况下,提升程序执行效率的解决方案是以 混合模式 运行程序。
使用混合模式时,首次调用某个方法时都是以解释器来执行的,但当发现某个方法是热方法时,则安排JIT编译器将之编译为本地代码。这种方法与上一节中提到的使用不同等级的JIT编译生成不同质量的本地代码类似,但执行速度更快。
对于现代JVM来说,能够在任何代码模型中检测出热方法是一项基本功能,后文会对此详细阐述。早期的混合模式策略中,通过记录方法的调用次数来查找热方法,如果调用次数超过了某个阈值,则启动JIT编译器执行优化编译工作。
与完全JIT编译类似,JVM只会对那些热方法进行优化编译,以期获得最好的执行效果,而对那些很少执行的方法,JVM根本不会花费时间去编译它们,但仍需要在每次调用它们时更新相关信息。
在混合模式中,代码的重新生成不再是关键问题。如果某个方法的本地代码需要重新生成,那么JVM会直接抛弃已经编译出的本地代码,下次调用该方法时由解释器解释执行。此后,如果该方法仍然够热,届时会重新执行优化编译工作。
Sun公司是第一家启用混合模式策略的JVM厂商,他们将之整合到HotSpot JIT编译器中,支持客户端版本和服务器端版本(后者对代码的优化编译能力更强),而HotSpot是基于收购了Longview Technologies有限公司(即Animorphic公司)获得的技术开发的。